factory-ai 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes follow semantic versioning and the Keep a Changelog structur
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.0] - 2026-07-13
8
+
9
+ ### Changed
10
+
11
+ - Redesigned the native TUI around a persistent workspace selector with workspace-scoped Dashboard, Objectives, and Agents pages.
12
+ - Consolidated secrets, logs, models, runtime, and capabilities under Settings.
13
+ - Added dedicated bounded command-console and always-visible command-line panes with scrollback, clear controls, history, inline errors, and selected-workspace submit shortcuts.
14
+
7
15
  ## [2.0.0] - 2026-07-13
8
16
 
9
17
  ### Changed
package/README.md CHANGED
@@ -96,7 +96,7 @@ factory ui
96
96
  | `factory secret set NAME` | Store a credential in global Key Vault |
97
97
  | `factory github connect ORG` | Connect GitHub Enterprise credentials |
98
98
 
99
- Inside `factory ui`, press `:` to enter any Factory command without the `factory` prefix. Use `↑/↓` for command history. Shortcuts: `n` submit, `i` import workspace, `a` set secret, `y/x` approve or deny, `p/u` pause or resume, `?` help, and `q` quit. Command output and errors remain visible inside the native TUI.
99
+ Inside `factory ui`, select a workspace from the persistent left sidebar with `[`/`]`; Dashboard, Objectives, and Agents are scoped to it. Settings contains secrets, logs, models, runtime, and capabilities. Press `:` or Enter to focus the dedicated command line and enter any Factory command without the `factory` prefix. Use `↑/↓` for command history and `Ctrl+↑/↓` for console scrollback. Shortcuts: `n` submit to the selected workspace, `i` import, `a` set secret, `y/x` approve or deny, `p/u` pause or resume, `?` help, `Ctrl+L` clear console, and `q` quit.
100
100
 
101
101
  ## Architecture
102
102
 
@@ -10,6 +10,7 @@ import (
10
10
  "os/exec"
11
11
  "path/filepath"
12
12
  "regexp"
13
+ "sort"
13
14
  "strconv"
14
15
  "strings"
15
16
  "sync"
@@ -110,24 +111,26 @@ type commandResultMsg struct {
110
111
  }
111
112
 
112
113
  type model struct {
113
- client *azblob.Client
114
- factoryName string
115
- purpose string
116
- width int
117
- height int
118
- tab int
119
- scroll int
120
- dashboard dashboard
121
- logs string
122
- workspaces []workspace
123
- workspaceErr string
124
- loading bool
125
- err error
126
- commandMode bool
127
- commandInput string
128
- commandOutput string
129
- commandHistory []string
130
- historyIndex int
114
+ client *azblob.Client
115
+ factoryName string
116
+ purpose string
117
+ width int
118
+ height int
119
+ tab int
120
+ scroll int
121
+ dashboard dashboard
122
+ logs string
123
+ workspaces []workspace
124
+ workspaceErr string
125
+ selectedWorkspace string
126
+ loading bool
127
+ err error
128
+ commandMode bool
129
+ commandInput string
130
+ commandOutput string
131
+ commandHistory []string
132
+ historyIndex int
133
+ consoleScroll int
131
134
  }
132
135
 
133
136
  var (
@@ -138,7 +141,7 @@ var (
138
141
  muted = lipgloss.Color("#7F8B99")
139
142
  panel = lipgloss.Color("#15191F")
140
143
  border = lipgloss.Color("#303743")
141
- tabs = []string{"Overview", "Workspaces", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"}
144
+ tabs = []string{"Dashboard", "Objectives", "Agents", "Settings"}
142
145
  ansi = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)`)
143
146
  )
144
147
 
@@ -406,13 +409,32 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
406
409
  m.scroll += 10
407
410
  case "home":
408
411
  m.scroll = 0
409
- case ":", "o":
412
+ case "[", "shift+up":
413
+ if index := m.selectedIndex(); index > 0 {
414
+ m.selectedWorkspace = m.workspaces[index-1].Name
415
+ m.scroll = 0
416
+ }
417
+ case "]", "shift+down":
418
+ if index := m.selectedIndex(); index >= 0 && index < len(m.workspaces)-1 {
419
+ m.selectedWorkspace = m.workspaces[index+1].Name
420
+ m.scroll = 0
421
+ }
422
+ case "ctrl+up":
423
+ if m.consoleScroll > 0 {
424
+ m.consoleScroll--
425
+ }
426
+ case "ctrl+down":
427
+ m.consoleScroll++
428
+ case ":", "/", "enter", "o":
410
429
  m.commandMode = true
411
430
  m.commandInput = ""
412
431
  m.historyIndex = len(m.commandHistory)
413
432
  case "n":
414
433
  m.commandMode = true
415
434
  m.commandInput = "submit "
435
+ if selected := m.selected(); selected != nil {
436
+ m.commandInput += selected.Name + " "
437
+ }
416
438
  m.historyIndex = len(m.commandHistory)
417
439
  case "i":
418
440
  m.commandMode = true
@@ -439,11 +461,15 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
439
461
  case "?":
440
462
  m.loading = true
441
463
  return m, executeFactoryCommand([]string{"--help"})
464
+ case "ctrl+l", "esc":
465
+ m.commandOutput = ""
466
+ m.consoleScroll = 0
467
+ m.err = nil
442
468
  case "r":
443
469
  m.loading = true
444
470
  return m, fetchCmd(m.client)
445
471
  default:
446
- if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '8' {
472
+ if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '4' {
447
473
  m.tab = int(msg.String()[0] - '1')
448
474
  m.scroll = 0
449
475
  }
@@ -455,16 +481,24 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
455
481
  m.err = msg.err
456
482
  if msg.err == nil {
457
483
  m.dashboard, m.logs, m.workspaces, m.workspaceErr = msg.dashboard, msg.logs, msg.workspaces, msg.workspaceErr
484
+ if len(m.workspaces) == 0 {
485
+ m.selectedWorkspace = ""
486
+ } else if m.selected() == nil {
487
+ m.selectedWorkspace = m.workspaces[0].Name
488
+ }
458
489
  }
459
490
  case commandResultMsg:
460
491
  m.loading = false
461
- m.err = msg.err
492
+ m.err = nil
462
493
  result := strings.TrimSpace(clean(msg.output))
463
494
  if msg.err != nil && result == "" {
464
495
  result = msg.err.Error()
465
496
  }
466
497
  m.commandOutput = fmt.Sprintf("$ %s\n%s", clean(msg.command), result)
467
- m.scroll = int(^uint(0) >> 1)
498
+ m.consoleScroll = len(strings.Split(m.commandOutput, "\n")) - 4
499
+ if m.consoleScroll < 0 {
500
+ m.consoleScroll = 0
501
+ }
468
502
  return m, fetchCmd(m.client)
469
503
  case tickMsg:
470
504
  if !m.loading {
@@ -499,15 +533,58 @@ func trim(value string, max int) string {
499
533
  return value
500
534
  }
501
535
 
536
+ func normalizeRepository(value string) string {
537
+ return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(value), "https://github.com/"), ".git")
538
+ }
539
+
540
+ func (m model) selected() *workspace {
541
+ for index := range m.workspaces {
542
+ if m.workspaces[index].Name == m.selectedWorkspace {
543
+ return &m.workspaces[index]
544
+ }
545
+ }
546
+ return nil
547
+ }
548
+
549
+ func (m model) selectedIndex() int {
550
+ for index, item := range m.workspaces {
551
+ if item.Name == m.selectedWorkspace {
552
+ return index
553
+ }
554
+ }
555
+ return -1
556
+ }
557
+
558
+ func (m model) visibleObjectives() []objective {
559
+ selected := m.selected()
560
+ if selected == nil {
561
+ return nil
562
+ }
563
+ repository := normalizeRepository(selected.Repository)
564
+ result := []objective{}
565
+ for _, item := range m.dashboard.Objectives {
566
+ if normalizeRepository(item.Repository) == repository {
567
+ result = append(result, item)
568
+ }
569
+ }
570
+ return result
571
+ }
572
+
502
573
  func (m model) overview() string {
503
574
  cost := "unavailable"
504
575
  if m.dashboard.Cost != nil {
505
576
  cost = fmt.Sprintf("%s %.2f", m.dashboard.Cost.Currency, m.dashboard.Cost.MonthToDate)
506
577
  }
578
+ objectives := m.visibleObjectives()
579
+ countMap := map[string]int{}
580
+ for _, item := range objectives {
581
+ countMap[item.Status]++
582
+ }
507
583
  counts := []string{}
508
- for key, value := range m.dashboard.Summary.Objectives {
584
+ for key, value := range countMap {
509
585
  counts = append(counts, fmt.Sprintf("%s %d", key, value))
510
586
  }
587
+ sort.Strings(counts)
511
588
  in, cached, out := 0, 0, 0
512
589
  for _, value := range m.dashboard.ModelUsage {
513
590
  in += value.InputTokens
@@ -515,8 +592,8 @@ func (m model) overview() string {
515
592
  out += value.OutputTokens
516
593
  }
517
594
  value := fmt.Sprintf("%s\n\nHealth %s\nQueue %s\nDead letters %d\nAzure MTD %s\nObjectives %s\nTokens %d in · %d cached · %d out\n\n%s\n\n", lipgloss.NewStyle().Bold(true).Render("SYSTEM"), badge(m.dashboard.Health.Status), lipgloss.NewStyle().Foreground(accent).Render(fmt.Sprint(m.dashboard.Queue.Active)), m.dashboard.Queue.DeadLetter, lipgloss.NewStyle().Foreground(warn).Render(cost), strings.Join(counts, " "), in, cached, out, lipgloss.NewStyle().Bold(true).Render("RECENT OBJECTIVES"))
518
- for index := len(m.dashboard.Objectives) - 1; index >= 0 && index >= len(m.dashboard.Objectives)-8; index-- {
519
- item := m.dashboard.Objectives[index]
595
+ for index := len(objectives) - 1; index >= 0 && index >= len(objectives)-8; index-- {
596
+ item := objectives[index]
520
597
  value += fmt.Sprintf("%s %s\n %s\n\n", badge(item.Status), trim(item.Objective, 90), lipgloss.NewStyle().Foreground(muted).Render(clean(item.Repository)))
521
598
  }
522
599
  return value
@@ -524,8 +601,9 @@ func (m model) overview() string {
524
601
 
525
602
  func (m model) objectives() string {
526
603
  var value strings.Builder
527
- for index := len(m.dashboard.Objectives) - 1; index >= 0; index-- {
528
- item := m.dashboard.Objectives[index]
604
+ objectives := m.visibleObjectives()
605
+ for index := len(objectives) - 1; index >= 0; index-- {
606
+ item := objectives[index]
529
607
  fmt.Fprintf(&value, "%s %s\n%s\n", badge(item.Status), trim(item.Objective, 100), lipgloss.NewStyle().Foreground(muted).Render(clean(item.ID)))
530
608
  for _, task := range item.Tasks {
531
609
  state := task.State
@@ -554,7 +632,7 @@ func (m model) objectives() string {
554
632
 
555
633
  func (m model) agents() string {
556
634
  var value strings.Builder
557
- for _, item := range m.dashboard.Objectives {
635
+ for _, item := range m.visibleObjectives() {
558
636
  for _, task := range item.Tasks {
559
637
  if task.State == "succeeded" {
560
638
  continue
@@ -586,47 +664,80 @@ func (m model) agents() string {
586
664
  return value.String()
587
665
  }
588
666
 
589
- func (m model) workspaceList() string {
667
+ func (m model) sidebar(maxLines int) string {
668
+ var value strings.Builder
669
+ value.WriteString(lipgloss.NewStyle().Bold(true).Render("WORKSPACES") + "\n\n")
590
670
  if m.workspaceErr != "" {
591
- return "Workspace catalog unavailable: " + clean(m.workspaceErr)
671
+ value.WriteString(lipgloss.NewStyle().Foreground(danger).Render("Catalog unavailable") + "\n")
592
672
  }
593
673
  if len(m.workspaces) == 0 {
594
- return "No workspaces imported. Press i and enter a local path or owner/repo."
674
+ value.WriteString("No workspaces\n\nPress i to import")
595
675
  }
596
- var value strings.Builder
597
- value.WriteString("IMPORTED WORKSPACES\n\n")
598
- for _, item := range m.workspaces {
599
- fmt.Fprintf(&value, "%s %s\n %s · %s\n\n", lipgloss.NewStyle().Foreground(accent).Bold(true).Render(clean(item.Name)), clean(item.Repository), clean(item.LocalPath), clean(item.BaseBranch))
676
+ available := maxLines - 7
677
+ if available < 1 {
678
+ available = 1
679
+ }
680
+ start := 0
681
+ if index := m.selectedIndex(); index >= available {
682
+ start = index - available + 1
683
+ }
684
+ end := start + available
685
+ if end > len(m.workspaces) {
686
+ end = len(m.workspaces)
687
+ }
688
+ if start > 0 {
689
+ value.WriteString(lipgloss.NewStyle().Foreground(muted).Render(" ↑ more") + "\n")
690
+ }
691
+ for _, item := range m.workspaces[start:end] {
692
+ marker := " "
693
+ style := lipgloss.NewStyle().Foreground(muted)
694
+ if item.Name == m.selectedWorkspace {
695
+ marker = "› "
696
+ style = style.Foreground(accent).Bold(true)
697
+ }
698
+ value.WriteString(style.Render(marker+trim(item.Name, 20)) + "\n")
699
+ }
700
+ if end < len(m.workspaces) {
701
+ value.WriteString(lipgloss.NewStyle().Foreground(muted).Render(" ↓ more") + "\n")
600
702
  }
703
+ value.WriteString("\n" + lipgloss.NewStyle().Foreground(muted).Render("[ / ] select\ni import\nn new objective"))
704
+ return value.String()
705
+ }
706
+
707
+ func (m model) settings() string {
708
+ var value strings.Builder
709
+ value.WriteString("RUNTIME\n")
710
+ fmt.Fprintf(&value, " Name %s\n Purpose %s\n Storage Azure Blob\n Refresh 15 seconds\n\n", clean(m.factoryName), clean(m.purpose))
711
+ value.WriteString("MODELS\n :models show\n :models set ROLE PROVIDER/MODEL\n\nSECRETS (values hidden)\n")
712
+ for _, item := range m.dashboard.Secrets {
713
+ fmt.Fprintf(&value, " ● %-36s %s\n", clean(item.Name), clean(item.Updated))
714
+ }
715
+ if len(m.dashboard.Secrets) == 0 {
716
+ value.WriteString(" No secret metadata available\n")
717
+ }
718
+ value.WriteString("\nCAPABILITIES\n Skills, MCP servers, scanners, ACP, and signed extensions\n :extension verify MANIFEST ARTIFACT PUBLIC_KEY\n")
719
+ value.WriteString("\nRECENT SERVICE LOGS\n")
720
+ lines := strings.Split(clean(m.logs), "\n")
721
+ if len(lines) > 30 {
722
+ lines = lines[len(lines)-30:]
723
+ }
724
+ value.WriteString(strings.Join(lines, "\n"))
601
725
  return value.String()
602
726
  }
603
727
 
604
728
  func (m model) body() string {
729
+ if m.tab < 3 && m.selected() == nil {
730
+ return "SELECT A WORKSPACE\n\nImport one with i, then select it from the left sidebar.\nObjectives and agents are scoped to the selected workspace."
731
+ }
605
732
  switch m.tab {
606
733
  case 0:
607
734
  return m.overview()
608
735
  case 1:
609
- return m.workspaceList()
610
- case 2:
611
736
  return m.objectives()
612
- case 3:
737
+ case 2:
613
738
  return m.agents()
614
- case 4:
615
- var b strings.Builder
616
- b.WriteString("GLOBAL KEY VAULT\nValues are never displayed.\n\n")
617
- for _, item := range m.dashboard.Secrets {
618
- fmt.Fprintf(&b, "● %-55s %s\n", clean(item.Name), clean(item.Updated))
619
- }
620
- return b.String()
621
- case 5:
622
- return "CURATED CAPABILITIES\n\nSkills: /goal, /loop, TDD, debugging, verification, security, release discipline\nMCP: Context7, Playwright, knowledge-graph memory\nScanners: Trivy, Gitleaks, OSV-Scanner, Semgrep"
623
- case 6:
624
- if m.logs == "" {
625
- return "No log snapshot available."
626
- }
627
- return clean(m.logs)
628
739
  default:
629
- return fmt.Sprintf("FACTORY SETTINGS\n\nName %s\nPurpose %s\nStorage Azure Blob snapshot\nRefresh 15 seconds\n\nUse `factory configure models` or `factory models set ROLE PROVIDER/MODEL` to change routing.", clean(m.factoryName), clean(m.purpose))
740
+ return m.settings()
630
741
  }
631
742
  }
632
743
 
@@ -636,6 +747,9 @@ func (m model) View() string {
636
747
  width = 80
637
748
  }
638
749
  header := lipgloss.NewStyle().Bold(true).Foreground(accent).Render(strings.ToUpper(clean(m.factoryName))) + " " + lipgloss.NewStyle().Foreground(muted).Render(clean(m.purpose))
750
+ if selected := m.selected(); selected != nil {
751
+ header += " " + lipgloss.NewStyle().Foreground(blue).Render("/ "+clean(selected.Name))
752
+ }
639
753
  tabValues := []string{}
640
754
  for index, value := range tabs {
641
755
  style := lipgloss.NewStyle().Padding(0, 1).Foreground(muted)
@@ -645,16 +759,21 @@ func (m model) View() string {
645
759
  tabValues = append(tabValues, style.Render(fmt.Sprintf("%d %s", index+1, value)))
646
760
  }
647
761
  tabLine := lipgloss.JoinHorizontal(lipgloss.Top, tabValues...)
648
- height := m.height - 6
649
- if height < 10 {
650
- height = 10
651
- }
652
- body := m.body()
762
+ consoleHeight := 0
653
763
  if m.commandOutput != "" {
654
- body += "\n\nCOMMAND OUTPUT\n" + m.commandOutput
764
+ consoleHeight = 7
765
+ }
766
+ mainHeight := m.height - 8 - consoleHeight
767
+ if mainHeight < 10 {
768
+ mainHeight = 10
655
769
  }
656
- lines := strings.Split(body, "\n")
657
- visible := height - 2
770
+ sidebarWidth := 26
771
+ contentWidth := width - sidebarWidth - 5
772
+ if contentWidth < 50 {
773
+ contentWidth = 50
774
+ }
775
+ lines := strings.Split(m.body(), "\n")
776
+ visible := mainHeight - 2
658
777
  maxScroll := len(lines) - visible
659
778
  if maxScroll < 0 {
660
779
  maxScroll = 0
@@ -667,17 +786,42 @@ func (m model) View() string {
667
786
  if end > len(lines) {
668
787
  end = len(lines)
669
788
  }
670
- content := lipgloss.NewStyle().Width(width-4).Height(height).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1, 2).Render(strings.Join(lines[scroll:end], "\n"))
671
- footer := ": command · n submit · i import · ? help · q quit"
789
+ sidebar := lipgloss.NewStyle().Width(sidebarWidth).Height(mainHeight).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1).Render(m.sidebar(mainHeight - 2))
790
+ content := lipgloss.NewStyle().Width(contentWidth).Height(mainHeight).Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(1, 2).Render(strings.Join(lines[scroll:end], "\n"))
791
+ main := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
792
+ console := ""
793
+ if m.commandOutput != "" {
794
+ consoleLines := strings.Split(clean(m.commandOutput), "\n")
795
+ maxConsoleScroll := len(consoleLines) - 4
796
+ if maxConsoleScroll < 0 {
797
+ maxConsoleScroll = 0
798
+ }
799
+ consoleScroll := m.consoleScroll
800
+ if consoleScroll > maxConsoleScroll {
801
+ consoleScroll = maxConsoleScroll
802
+ }
803
+ consoleEnd := consoleScroll + 4
804
+ if consoleEnd > len(consoleLines) {
805
+ consoleEnd = len(consoleLines)
806
+ }
807
+ console = lipgloss.NewStyle().Width(width-2).Height(5).Border(lipgloss.RoundedBorder()).BorderForeground(muted).Padding(0, 1).Render(fmt.Sprintf("CONSOLE · ctrl+↑/↓ scroll · ctrl+l clear [%d-%d/%d]\n%s", consoleScroll+1, consoleEnd, len(consoleLines), strings.Join(consoleLines[consoleScroll:consoleEnd], "\n")))
808
+ }
809
+ prompt := "Press : to enter a Factory command · n submit · i import · ? help · q quit"
672
810
  if m.commandMode {
673
- footer = lipgloss.NewStyle().Foreground(accent).Bold(true).Render(": ") + clean(m.commandInput) + "█"
811
+ prompt = lipgloss.NewStyle().Foreground(accent).Bold(true).Render(" ") + clean(m.commandInput) + "█"
674
812
  } else if m.loading {
675
- footer = "Refreshing snapshot…"
813
+ prompt = "Refreshing Factory state…"
676
814
  }
677
815
  if m.err != nil && !m.commandMode {
678
- footer = lipgloss.NewStyle().Foreground(danger).Render(m.err.Error())
816
+ prompt = lipgloss.NewStyle().Foreground(danger).Render(m.err.Error())
817
+ }
818
+ commandLine := lipgloss.NewStyle().Width(width-2).Height(1).Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1).Render(prompt)
819
+ parts := []string{header, tabLine, main}
820
+ if console != "" {
821
+ parts = append(parts, console)
679
822
  }
680
- return lipgloss.JoinVertical(lipgloss.Left, header, tabLine, content, lipgloss.NewStyle().Foreground(muted).Render(footer))
823
+ parts = append(parts, commandLine)
824
+ return lipgloss.JoinVertical(lipgloss.Left, parts...)
681
825
  }
682
826
 
683
827
  func main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factory-ai",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Deploy a private autonomous coding-agent factory on Azure: isolated builders, testers, security reviewers, durable orchestration, multi-model routing, memory, cost controls, and gated GitHub pull requests.",
5
5
  "private": false,
6
6
  "license": "MIT",